C.S.Burner 🪙 ━►🔥GIT Node
Node / reticulum_mirror / MeshChatX / files / meshchatx / src / frontend / components / contacts / ContactsPage.vue
Displaying Raw • Download
meshchatx/src/frontend/components/contacts/ContactsPage.vue 65ca9ddc823fcc2c8f8653c553b944b85db0875f (65ca9ddc) Text, 47.00 KB
T8b949e<!-- SPDX-License-Identifier: 0BSD -->
Tff7b72<templateTff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex flex-1 min-w-0 h-full overflow-hidden bg-slate-50 dark:bg-zinc-950Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex-1 overflow-y-auto p-4 md:p-6Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffmax-w-5xl mx-auto space-y-0 border-b border-gray-200 dark:border-zinc-800 pb-6Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex flex-col gap-3 lg:flex-row lg:items-center lg:justify-betweenTa5d6ff"Tff7b72>
Tff7b72<divTff7b72>
Tff7b72<h1 Te6edf3class=Ta5d6ff"Ta5d6fftext-2xl font-bold text-gray-900 dark:text-zinc-100Ta5d6ff"Tff7b72>{{ Te6edf3$t("contacts.title") }}Tff7b72</h1>
Tff7b72<p Te6edf3class=Ta5d6ff"Ta5d6fftext-sm text-gray-600 dark:text-zinc-400Ta5d6ff"Tff7b72>
{{ Te6edf3$t("contacts.description") }}
Tff7b72</p>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex flex-wrap items-center gap-2Ta5d6ff"Tff7b72>
Tff7b72<button Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsecondary-chipTa5d6ff" @Te6edf3click=Ta5d6ff"Ta5d6ffopenMyIdentityDialogTa5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffqrcodeTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
{{ Te6edf3$t("contacts.share_my_identity") }}
Tff7b72</button>
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffsecondary-chip justify-center px-3 sm:px-4Ta5d6ff"
Te6edf3:disabled=Ta5d6ff"Ta5d6fftotalContactsCount === 0Ta5d6ff"
Te6edf3:title=Ta5d6ff"Te6edf3$tTa5d6ff(Ta5d6ff'Ta5d6ffcontacts.export_contactsTa5d6ff'Ta5d6ff)Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffexportContactsTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6fffile-exportTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
Tff7b72<span Te6edf3class=Ta5d6ff"Ta5d6ffhidden sm:inlineTa5d6ff"Tff7b72>{{ Te6edf3$t("contacts.export_contacts") }}Tff7b72</span>
Tff7b72</button>
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffsecondary-chip justify-center px-3 sm:px-4Ta5d6ff"
Te6edf3:title=Ta5d6ff"Te6edf3$tTa5d6ff(Ta5d6ff'Ta5d6ffcontacts.import_contactsTa5d6ff'Ta5d6ff)Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffopenImportDialogTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6fffile-importTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
Tff7b72<span Te6edf3class=Ta5d6ff"Ta5d6ffhidden sm:inlineTa5d6ff"Tff7b72>{{ Te6edf3$t("contacts.import_contacts") }}Tff7b72</span>
Tff7b72</button>
Tff7b72<button Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffprimary-chip hidden sm:inline-flexTa5d6ff" @Te6edf3click=Ta5d6ff"Ta5d6ffopenAddDialogTa5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffplusTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
{{ Te6edf3$t("contacts.add_contact") }}
Tff7b72</button>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffmax-w-5xl mx-auto space-y-0 pt-4Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffborder-b border-gray-200 dark:border-zinc-800 pb-3Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffrelative groupTa5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon
Te6edf3icon-name=Ta5d6ff"Ta5d6ffmagnifyTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffabsolute left-3 top-1/2 -translate-y-1/2 size-5 shrink-0 text-gray-400 group-focus-within:text-blue-500 transition-colors pointer-events-none z-10Ta5d6ff"
Tff7b72/>
Tff7b72<input
Te6edf3v-model=Ta5d6ff"Ta5d6ffcontactsSearchTa5d6ff"
Te6edf3type=Ta5d6ff"Ta5d6fftextTa5d6ff"
Te6edf3:placeholder=Ta5d6ff"Te6edf3$tTa5d6ff(Ta5d6ff'Ta5d6ffcontacts.search_placeholderTa5d6ff'Ta5d6ff)Ta5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffinput-field pl-11!Ta5d6ff"
@Te6edf3input=Ta5d6ff"Ta5d6ffonContactsSearchInputTa5d6ff"
Tff7b72/>
Tff7b72</div>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffmin-w-0Ta5d6ff"Tff7b72>
Tff7b72<template Te6edf3v-if=Ta5d6ff"Ta5d6ffisLoading && contacts.length === 0Ta5d6ff"Tff7b72>
Tff7b72<div
Te6edf3v-for=Ta5d6ff"Ta5d6ffi in 8Ta5d6ff"
Te6edf3:key=Ta5d6ff"Ta5d6ff'Ta5d6ffskeleton-Ta5d6ff'Ta5d6ff + iTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center gap-3 border-b border-gray-100 px-1 py-3 dark:border-zinc-800Ta5d6ff"
Tff7b72>
Tff7b72<div
Te6edf3class=Ta5d6ff"Ta5d6ffsize-10 sm:size-12 rounded-full bg-gray-200 dark:bg-zinc-700 animate-pulse shrink-0Ta5d6ff"
Tff7b72/>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex-1 min-w-0 space-y-2Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffh-4 w-32 bg-gray-200 dark:bg-zinc-700 rounded-sm animate-pulseTa5d6ff" Tff7b72/>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffh-3 w-48 bg-gray-100 dark:bg-zinc-800 rounded-sm animate-pulseTa5d6ff" Tff7b72/>
Tff7b72</div>
Tff7b72</div>
Tff7b72</template>
Tff7b72<div
Te6edf3v-else-if=Ta5d6ff"Ta5d6ff!isLoading && contacts.length === 0Ta5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffpy-10 text-center text-gray-500 dark:text-zinc-400Ta5d6ff"
Tff7b72>
{{ Te6edf3$t("contacts.no_contacts") }}
Tff7b72</div>
Tff7b72<div v-else Te6edf3class=Ta5d6ff"Ta5d6ffdivide-y divide-gray-100 dark:divide-zinc-800Ta5d6ff"Tff7b72>
Tff7b72<div
Te6edf3v-for=Ta5d6ff"Ta5d6ffcontact in mergedContactsTa5d6ff"
Te6edf3:key=Ta5d6ff"Ta5d6ffcontact.idTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffgroup flex cursor-default items-center gap-3 px-1 py-3 transition-colors hover:bg-gray-50/80 dark:hover:bg-zinc-900/70Ta5d6ff"
@contextmenu.Te6edf3prevent=Ta5d6ff"Ta5d6ffopenContextMenu(Te6edf3$eventTa5d6ff, contact)Ta5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffshrink-0Ta5d6ff"Tff7b72>
Tff7b72<LxmfUserIcon
Te6edf3:custom-image=Ta5d6ff"Ta5d6ffcontact.custom_imageTa5d6ff"
Te6edf3:icon-name=Ta5d6ff"Ta5d6ffcontact.remote_icon ? contact.remote_icon.icon_name : Ta5d6ff'Ta5d6ff'Ta5d6ff"
Te6edf3:icon-foreground-colour=Ta5d6ff"
Ta5d6ff contact.remote_icon ? contact.remote_icon.foreground_colour : Ta5d6ff'Ta5d6ff'
Ta5d6ff Ta5d6ff"
Te6edf3:icon-background-colour=Ta5d6ff"
Ta5d6ff contact.remote_icon ? contact.remote_icon.background_colour : Ta5d6ff'Ta5d6ff'
Ta5d6ff Ta5d6ff"
Te6edf3icon-class=Ta5d6ff"Ta5d6ffsize-10 sm:size-12Ta5d6ff"
Tff7b72/>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffmin-w-0 flex-1Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6fffont-semibold text-gray-900 dark:text-zinc-100 truncateTa5d6ff"Tff7b72>
{{ contact.name }}
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex flex-col gap-0.5Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3v-if=Ta5d6ff"Ta5d6ffcontact.remote_destination_hashTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center gap-1.5Ta5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon
Te6edf3icon-name=Ta5d6ff"Ta5d6ffmessage-text-outlineTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffsize-4 text-blue-500 dark:text-blue-400 shrink-0Ta5d6ff"
Tff7b72/>
Tff7b72<span Te6edf3class=Ta5d6ff"Ta5d6fftext-xs font-mono text-gray-500 dark:text-zinc-400 break-allTa5d6ff"Tff7b72>{{
contact.remote_destination_hash
}}Tff7b72</span>
Tff7b72</div>
Tff7b72<div Te6edf3v-if=Ta5d6ff"Ta5d6ffcontact.remote_telephony_hashTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center gap-1.5Ta5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon
Te6edf3icon-name=Ta5d6ff"Ta5d6ffphone-outlineTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffsize-4 text-green-600 dark:text-green-400 shrink-0Ta5d6ff"
Tff7b72/>
Tff7b72<span Te6edf3class=Ta5d6ff"Ta5d6fftext-xs font-mono text-gray-500 dark:text-zinc-400 break-allTa5d6ff"Tff7b72>{{
contact.remote_telephony_hash
}}Tff7b72</span>
Tff7b72</div>
Tff7b72<span
Te6edf3v-if=Ta5d6ff"Ta5d6ff!contact.remote_destination_hash && !contact.remote_telephony_hashTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6fftext-xs font-mono text-gray-500 dark:text-zinc-400 break-allTa5d6ff"
Tff7b72>{{ contact.lxmf_address || contact.remote_identity_hash }}Tff7b72</span
>
Tff7b72</div>
Tff7b72</div>
Tff7b72<div
Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center gap-1 opacity-100 transition-opacity lg:opacity-0 lg:group-hover:opacity-100Ta5d6ff"
Tff7b72>
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffp-1.5 rounded-lg text-gray-500 dark:text-zinc-400 hover:bg-blue-100 dark:hover:bg-blue-900/40 hover:text-blue-600 dark:hover:text-blue-400 transition-colorsTa5d6ff"
Te6edf3:title=Ta5d6ff"Te6edf3$tTa5d6ff(Ta5d6ff'Ta5d6ffcontacts.send_messageTa5d6ff'Ta5d6ff)Ta5d6ff"
@click.Te6edf3stop=Ta5d6ff"Ta5d6ffopenConversation(contact)Ta5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffmessage-text-outlineTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-5Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffp-1.5 rounded-lg text-gray-500 dark:text-zinc-400 hover:bg-green-100 dark:hover:bg-green-900/40 hover:text-green-600 dark:hover:text-green-400 transition-colorsTa5d6ff"
Te6edf3:title=Ta5d6ff"Te6edf3$tTa5d6ff(Ta5d6ff'Ta5d6ffcontacts.call_contactTa5d6ff'Ta5d6ff)Ta5d6ff"
@click.Te6edf3stop=Ta5d6ff"Ta5d6ffcallContact(contact)Ta5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffphone-outlineTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-5Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72</div>
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffp-1.5 rounded-lg text-gray-500 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800 hover:text-gray-700 dark:hover:text-zinc-200 transition-colorsTa5d6ff"
Te6edf3:title=Ta5d6ff"Te6edf3$tTa5d6ff(Ta5d6ff'Ta5d6ffcontacts.actionsTa5d6ff'Ta5d6ff)Ta5d6ff"
@click.Te6edf3stop=Ta5d6ff"Ta5d6ffopenContextMenu(Te6edf3$eventTa5d6ff, contact)Ta5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffdots-verticalTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-5Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72</div>
Tff7b72<div Te6edf3v-if=Ta5d6ff"Ta5d6ffhasMoreContacts && !isLoadingMoreTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffpt-2 flex justify-centerTa5d6ff"Tff7b72>
Tff7b72<button Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsecondary-chipTa5d6ff" @Te6edf3click=Ta5d6ff"Ta5d6ffloadMoreContactsTa5d6ff"Tff7b72>
{{ Te6edf3$t("contacts.load_more") }}
Tff7b72</button>
Tff7b72</div>
Tff7b72<div Te6edf3v-if=Ta5d6ff"Ta5d6ffisLoadingMoreTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffpy-3 flex justify-centerTa5d6ff"Tff7b72>
Tff7b72<div
Te6edf3class=Ta5d6ff"Ta5d6ffsize-6 border-2 border-blue-500/30 border-t-blue-500 rounded-full animate-spinTa5d6ff"
Tff7b72/>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffsm:hidden fixed bottom-5 right-4 z-180 flex h-14 w-14 items-center justify-center rounded-full bg-blue-600 text-white shadow-lg ring-1 ring-blue-400/30 transition active:scale-95Ta5d6ff"
Te6edf3:title=Ta5d6ff"Te6edf3$tTa5d6ff(Ta5d6ff'Ta5d6ffcontacts.add_contactTa5d6ff'Ta5d6ff)Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffopenAddDialogTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffplusTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-7Ta5d6ff" Tff7b72/>
Tff7b72</button>
T8b949e<!-- Contact context menu -->
Tff7b72<ContextMenuPanel Te6edf3:show=Ta5d6ff"Ta5d6ffcontextMenu.visibleTa5d6ff" Te6edf3:x=Ta5d6ff"Ta5d6ffcontextMenu.xTa5d6ff" Te6edf3:y=Ta5d6ff"Ta5d6ffcontextMenu.yTa5d6ff" Te6edf3panel-class=Ta5d6ff"Ta5d6ffz-210Ta5d6ff"Tff7b72>
Tff7b72<ContextMenuItem @Te6edf3click=Ta5d6ff"Ta5d6ffopenConversation(contextMenu.contact)Ta5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffmessage-text-outlineTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
{{ Te6edf3$t("contacts.send_message") }}
Tff7b72</ContextMenuItem>
Tff7b72<ContextMenuItem @Te6edf3click=Ta5d6ff"Ta5d6ffcallContact(contextMenu.contact)Ta5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffphone-outlineTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
{{ Te6edf3$t("contacts.call_contact") }}
Tff7b72</ContextMenuItem>
Tff7b72<ContextMenuDivider Tff7b72/>
Tff7b72<ContextMenuItem @Te6edf3click=Ta5d6ff"Ta5d6ffeditContactName(contextMenu.contact)Ta5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffpencil-outlineTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
{{ Te6edf3$t("contacts.edit_contact") }}
Tff7b72</ContextMenuItem>
Tff7b72<ContextMenuItem @Te6edf3click=Ta5d6ff"Ta5d6ffshareContact(contextMenu.contact)Ta5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffshare-variantTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
{{ Te6edf3$t("contacts.share_contact") }}
Tff7b72</ContextMenuItem>
Tff7b72<ContextMenuItem @Te6edf3click=Ta5d6ff"Ta5d6ffcopyContactUri(contextMenu.contact)Ta5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffcontent-copyTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
{{ Te6edf3$t("contacts.copy_contact_uri") }}
Tff7b72</ContextMenuItem>
Tff7b72<ContextMenuDivider Tff7b72/>
Tff7b72<ContextMenuItem Te6edf3item-class=Ta5d6ff"Ta5d6fftext-red-600 dark:text-red-400Ta5d6ff" @Te6edf3click=Ta5d6ff"Ta5d6ffremoveContact(contextMenu.contact)Ta5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffdelete-outlineTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
{{ Te6edf3$t("contacts.remove_contact") }}
Tff7b72</ContextMenuItem>
Tff7b72</ContextMenuPanel>
T8b949e<!-- Add contact dialog -->
Tff7b72<div
Te6edf3v-if=Ta5d6ff"Ta5d6ffisAddDialogOpenTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6fffixed inset-0 z-200 flex items-center justify-center p-4 bg-black/50 backdrop-blur-xsTa5d6ff"
@click.Te6edf3self=Ta5d6ff"Ta5d6ffcloseAddDialogTa5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffw-full max-w-lg rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl overflow-hiddenTa5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffpx-5 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-betweenTa5d6ff"Tff7b72>
Tff7b72<h3 Te6edf3class=Ta5d6ff"Ta5d6fftext-lg font-bold text-gray-900 dark:text-zinc-100Ta5d6ff"Tff7b72>{{ Te6edf3$t("contacts.add_contact") }}Tff7b72</h3>
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6fftext-gray-400 hover:text-gray-600 dark:hover:text-zinc-300Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffcloseAddDialogTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffcloseTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-5Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffp-5 space-y-4Ta5d6ff"Tff7b72>
Tff7b72<divTff7b72>
Tff7b72<label Te6edf3class=Ta5d6ff"Ta5d6ffblock text-xs uppercase tracking-wider font-semibold text-gray-500 mb-1Ta5d6ff"Tff7b72>
{{ Te6edf3$t("contacts.contact_name_optional") }}
Tff7b72</label>
Tff7b72<input
Te6edf3v-model=Ta5d6ff"Ta5d6ffnewContactNameTa5d6ff"
Te6edf3type=Ta5d6ff"Ta5d6fftextTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffinput-fieldTa5d6ff"
Te6edf3:placeholder=Ta5d6ff"Te6edf3$tTa5d6ff(Ta5d6ff'Ta5d6ffcontacts.contact_name_placeholderTa5d6ff'Ta5d6ff)Ta5d6ff"
Tff7b72/>
Tff7b72</div>
Tff7b72<divTff7b72>
Tff7b72<label Te6edf3class=Ta5d6ff"Ta5d6ffblock text-xs uppercase tracking-wider font-semibold text-gray-500 mb-1Ta5d6ff"Tff7b72>
{{ Te6edf3$t("contacts.hash_or_uri") }}
Tff7b72</label>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffrelativeTa5d6ff"Tff7b72>
Tff7b72<input
Te6edf3v-model=Ta5d6ff"Ta5d6ffnewContactInputTa5d6ff"
Te6edf3type=Ta5d6ff"Ta5d6fftextTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffinput-field font-monoTa5d6ff"
Te6edf3:class=Ta5d6ff"Ta5d6ffcameraSupported ? Ta5d6ff'Ta5d6ffpr-12!Ta5d6ff'Ta5d6ff : Ta5d6ff'Ta5d6ff'Ta5d6ff"
Te6edf3:placeholder=Ta5d6ff"Te6edf3$tTa5d6ff(Ta5d6ff'Ta5d6ffcontacts.hash_or_uri_placeholderTa5d6ff'Ta5d6ff)Ta5d6ff"
@keydown.enter.Te6edf3prevent=Ta5d6ff"Ta5d6ffsubmitAddContactTa5d6ff"
Tff7b72/>
Tff7b72<button
Te6edf3v-if=Ta5d6ff"Ta5d6ffcameraSupportedTa5d6ff"
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffabsolute right-2 top-1/2 -translate-y-1/2 inline-flex items-center justify-center w-8 h-8 rounded-lg text-gray-500 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/40 transitionTa5d6ff"
Te6edf3:title=Ta5d6ff"Te6edf3$tTa5d6ff(Ta5d6ff'Ta5d6ffcontacts.scan_qrTa5d6ff'Ta5d6ff)Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffopenScannerDialogTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffqrcode-scanTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-5Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72</div>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex flex-wrap gap-2Ta5d6ff"Tff7b72>
Tff7b72<button Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsecondary-chipTa5d6ff" @Te6edf3click=Ta5d6ff"Ta5d6ffpasteFromClipboardTa5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffclipboard-text-outlineTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
{{ Te6edf3$t("contacts.paste") }}
Tff7b72</button>
Tff7b72<button Te6edf3v-if=Ta5d6ff"Ta5d6ffcameraSupportedTa5d6ff" Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsecondary-chipTa5d6ff" @Te6edf3click=Ta5d6ff"Ta5d6ffopenScannerDialogTa5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffqrcode-scanTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
{{ Te6edf3$t("contacts.scan_qr") }}
Tff7b72</button>
Tff7b72</div>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffpx-5 py-4 border-t border-gray-100 dark:border-zinc-800 flex justify-end gap-2Ta5d6ff"Tff7b72>
Tff7b72<button Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsecondary-chipTa5d6ff" @Te6edf3click=Ta5d6ff"Ta5d6ffcloseAddDialogTa5d6ff"Tff7b72>
{{ Te6edf3$t("common.cancel") }}
Tff7b72</button>
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffprimary-chipTa5d6ff"
Te6edf3:disabled=Ta5d6ff"Ta5d6ff!newContactInput || isSubmittingTa5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffsubmitAddContactTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon
Te6edf3:icon-name=Ta5d6ff"Ta5d6ffisSubmitting ? Ta5d6ff'Ta5d6ffloadingTa5d6ff'Ta5d6ff : Ta5d6ff'Ta5d6ffcheckTa5d6ff'Ta5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff"
Te6edf3:class=Ta5d6ff"Ta5d6ff{ Ta5d6ff'Ta5d6ffanimate-spinTa5d6ff'Ta5d6ff: isSubmitting }Ta5d6ff"
Tff7b72/>
{{ Te6edf3$t("contacts.add_contact") }}
Tff7b72</button>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
T8b949e<!-- Scanner dialog -->
Tff7b72<div
Te6edf3v-if=Ta5d6ff"Ta5d6ffisScannerDialogOpenTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6fffixed inset-0 z-220 flex items-center justify-center p-4 bg-black/70 backdrop-blur-xsTa5d6ff"
@click.Te6edf3self=Ta5d6ff"Ta5d6ffcloseScannerDialogTa5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffw-full max-w-xl rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl overflow-hiddenTa5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffpx-5 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-betweenTa5d6ff"Tff7b72>
Tff7b72<h3 Te6edf3class=Ta5d6ff"Ta5d6fftext-lg font-bold text-gray-900 dark:text-zinc-100Ta5d6ff"Tff7b72>{{ Te6edf3$t("contacts.scan_qr") }}Tff7b72</h3>
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6fftext-gray-400 hover:text-gray-600 dark:hover:text-zinc-300Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffcloseScannerDialogTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffcloseTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-5Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffp-5 space-y-3Ta5d6ff"Tff7b72>
Tff7b72<video
Te6edf3ref=Ta5d6ff"Ta5d6ffscannerVideoTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffw-full rounded-xl bg-black max-h-[60vh]Ta5d6ff"
autoplay
playsinline
muted
Tff7b72>Tff7b72</video>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6fftext-sm text-gray-500 dark:text-zinc-400Ta5d6ff"Tff7b72>
{{ scannerError || Te6edf3$t("contacts.scanner_hint") }}
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
T8b949e<!-- Import contacts dialog -->
Tff7b72<div
Te6edf3v-if=Ta5d6ff"Ta5d6ffisImportDialogOpenTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6fffixed inset-0 z-200 flex items-center justify-center p-4 bg-black/50 backdrop-blur-xsTa5d6ff"
@click.Te6edf3self=Ta5d6ff"Ta5d6ffcloseImportDialogTa5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffw-full max-w-lg rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl overflow-hiddenTa5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffpx-5 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-betweenTa5d6ff"Tff7b72>
Tff7b72<h3 Te6edf3class=Ta5d6ff"Ta5d6fftext-lg font-bold text-gray-900 dark:text-zinc-100Ta5d6ff"Tff7b72>
{{ Te6edf3$t("contacts.import_modal_title") }}
Tff7b72</h3>
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6fftext-gray-400 hover:text-gray-600 dark:hover:text-zinc-300Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffcloseImportDialogTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffcloseTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-5Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffp-5 space-y-4Ta5d6ff"Tff7b72>
Tff7b72<p Te6edf3class=Ta5d6ff"Ta5d6fftext-sm text-gray-600 dark:text-zinc-400Ta5d6ff"Tff7b72>
{{ Te6edf3$t("contacts.import_file_hint") }}
Tff7b72</p>
Tff7b72<input
Te6edf3ref=Ta5d6ff"Ta5d6ffimportFileInputTa5d6ff"
Te6edf3type=Ta5d6ff"Ta5d6fffileTa5d6ff"
Te6edf3accept=Ta5d6ff"Ta5d6ff.json,application/jsonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffhiddenTa5d6ff"
@Te6edf3change=Ta5d6ff"Ta5d6ffonImportFileChangeTa5d6ff"
Tff7b72/>
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffsecondary-chip w-full justify-centerTa5d6ff"
@Te6edf3click=Ta5d6ff"Te6edf3$refs.importFileInputTa5d6ff?.click()Ta5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6fffile-uploadTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
{{ Te6edf3$t("contacts.import_contacts") }}
Tff7b72</button>
Tff7b72<p Te6edf3v-if=Ta5d6ff"Ta5d6ffimportErrorTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6fftext-sm text-red-600 dark:text-red-400Ta5d6ff"Tff7b72>{{ importError }}Tff7b72</p>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
T8b949e<!-- My identity dialog -->
Tff7b72<div
Te6edf3v-if=Ta5d6ff"Ta5d6ffisMyIdentityDialogOpenTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6fffixed inset-0 z-200 flex items-center justify-center p-4 bg-black/50 backdrop-blur-xsTa5d6ff"
@click.Te6edf3self=Ta5d6ff"Ta5d6ffisMyIdentityDialogOpen = falseTa5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffw-full max-w-md rounded-2xl bg-white dark:bg-zinc-900 shadow-2xl overflow-hiddenTa5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffpx-5 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-betweenTa5d6ff"Tff7b72>
Tff7b72<h3 Te6edf3class=Ta5d6ff"Ta5d6fftext-lg font-bold text-gray-900 dark:text-zinc-100Ta5d6ff"Tff7b72>
{{ Te6edf3$t("contacts.share_my_identity") }}
Tff7b72</h3>
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6fftext-gray-400 hover:text-gray-600 dark:hover:text-zinc-300Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffisMyIdentityDialogOpen = falseTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffcloseTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-5Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffp-5 space-y-4Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex justify-centerTa5d6ff"Tff7b72>
Tff7b72<img
Te6edf3v-if=Ta5d6ff"Ta5d6ffmyQrDataUrlTa5d6ff"
Te6edf3:src=Ta5d6ff"Ta5d6ffmyQrDataUrlTa5d6ff"
Te6edf3alt=Ta5d6ff"Ta5d6ffIdentity QRTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffw-52 h-52 rounded-xl border border-gray-200 dark:border-zinc-800 bg-whiteTa5d6ff"
Tff7b72/>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6fftext-xs font-mono break-all text-center text-gray-600 dark:text-zinc-300Ta5d6ff"Tff7b72>
{{ myIdentityUri }}
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex justify-center gap-2Ta5d6ff"Tff7b72>
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffsecondary-chipTa5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffcopyToClipboard(myIdentityUri, Te6edf3$tTa5d6ff(Ta5d6ff'Ta5d6ffcontacts.identity_uri_copiedTa5d6ff'Ta5d6ff))Ta5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffcontent-copyTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
{{ Te6edf3$t("common.copy") }}
Tff7b72</button>
Tff7b72<button Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffprimary-chipTa5d6ff" @Te6edf3click=Ta5d6ff"Ta5d6ffshareUri(myIdentityUri)Ta5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffshare-variantTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
{{ Te6edf3$t("contacts.share") }}
Tff7b72</button>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
Tff7b72</template>
Tff7b72<scriptTff7b72>
import QRCode from "qrcode";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import WebSocketConnection from "../../js/WebSocketConnection";
import ToastUtils from "../../js/ToastUtils";
import DownloadUtils from "../../js/DownloadUtils";
import DialogUtils from "../../js/DialogUtils";
import {
attachStreamToVideo,
decodeQrFromVideo,
describeCameraError as describeQrCameraError,
isCameraSupported,
startCameraStream,
} from "../../js/qrScannerUtils";
import LxmfUserIcon from "../LxmfUserIcon.vue";
import ContextMenuDivider from "../contextmenu/ContextMenuDivider.vue";
import ContextMenuItem from "../contextmenu/ContextMenuItem.vue";
import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
export default {
name: "ContactsPage",
components: {
MaterialDesignIcon,
LxmfUserIcon,
ContextMenuDivider,
ContextMenuItem,
ContextMenuPanel,
},
data() {
return {
contacts: [],
contactsSearch: "",
isLoading: false,
isLoadingMore: false,
searchDebounceTimeout: null,
contactsPageSize: 30,
contactsOffset: 0,
totalContactsCount: 0,
config: null,
myIdentityUri: null,
myQrDataUrl: null,
isMyIdentityDialogOpen: false,
isAddDialogOpen: false,
isSubmitting: false,
newContactName: "",
newContactInput: "",
isScannerDialogOpen: false,
scannerError: null,
scannerStream: null,
scannerAnimationFrame: null,
pendingLxmaImport: false,
contextMenu: {
visible: false,
x: 0,
y: 0,
contact: null,
},
isImportDialogOpen: false,
importError: null,
};
},
computed: {
cameraSupported() {
return isCameraSupported();
},
hasMoreContacts() {
return this.contacts.length Tff7b72< this.totalContactsCount;
},
mergedContacts() {
const Te6edf3map = Ta5d6ffnew Map();
for (const c of this.contacts) {
const Te6edf3key = Ta5d6ffc.name?.toLowerCase() || "";
if (!map.has(key)) {
map.set(key, { ...c });
} else {
const Te6edf3existing = Ta5d6ffmap.get(key);
// Merge fields so both LXMF and LXST addresses are visible
Te6edf3existing.lxmf_address = Ta5d6ffexisting.lxmf_address || c.lxmf_address;
Te6edf3existing.lxst_address = Ta5d6ffexisting.lxst_address || c.lxst_address;
Te6edf3existing.remote_destination_hash = Ta5d6ffexisting.remote_destination_hash || c.remote_destination_hash;
Te6edf3existing.remote_telephony_hash = Ta5d6ffexisting.remote_telephony_hash || c.remote_telephony_hash;
Te6edf3existing.remote_identity_hash = Ta5d6ffexisting.remote_identity_hash || c.remote_identity_hash;
}
}
return Array.from(map.values());
},
},
beforeUnmount() {
WebSocketConnection.off("message", this.onWebsocketMessage);
document.removeEventListener("click", this.closeContextMenu);
this.stopScanner();
if (this.searchDebounceTimeout) {
clearTimeout(this.searchDebounceTimeout);
}
},
async mounted() {
document.addEventListener("click", this.closeContextMenu);
WebSocketConnection.on("message", this.onWebsocketMessage);
await this.getConfig();
await this.getContacts();
},
methods: {
async getConfig() {
try {
const Te6edf3response = Ta5d6ffawait window.api.get("/api/v1/config");
Te6edf3this.config = Ta5d6ffresponse.data.config;
Te6edf3this.myIdentityUri = Ta5d6ffthis.buildMyIdentityUri();
if (this.myIdentityUri) {
Te6edf3this.myQrDataUrl = Ta5d6ffawait QRCode.toDataURL(this.myIdentityUri, { margin: 1, scale: 6 });
}
} catch (e) {
console.log(e);
}
},
buildMyIdentityUri() {
if (!this.config?.lxmf_address_hash) return null;
if (this.config?.identity_public_key) {
return `lxma://${this.config.lxmf_address_hash}:${this.config.identity_public_key}`;
}
return `lxmf://${this.config.lxmf_address_hash}`;
},
async getContacts(Te6edf3append = Ta5d6fffalse) {
if (append) {
Te6edf3this.isLoadingMore = Ta5d6fftrue;
} else {
Te6edf3this.isLoading = Ta5d6fftrue;
Te6edf3this.contactsOffset = Ta5d6ff0;
}
try {
const Te6edf3response = Ta5d6ffawait window.api.get("/api/v1/telephone/contacts", {
params: {
search: this.contactsSearch || undefined,
limit: this.contactsPageSize,
offset: this.contactsOffset,
},
});
const Te6edf3list = Ta5d6ffresponse.data?.contacts ?? (Array.isArray(response.data) ? response.data : []);
Te6edf3this.totalContactsCount = Ta5d6ffresponse.data?.total_count ?? list.length;
if (append) {
Te6edf3this.contacts = Ta5d6ff[...this.contacts, ...list];
} else {
Te6edf3this.contacts = Ta5d6fflist;
}
this.contactsOffset += list.length;
} catch (e) {
console.log(e);
ToastUtils.error(this.$t("contacts.failed_load_contacts"));
} finally {
Te6edf3this.isLoading = Ta5d6fffalse;
Te6edf3this.isLoadingMore = Ta5d6fffalse;
}
},
loadMoreContacts() {
if (this.isLoadingMore || !this.hasMoreContacts) return;
this.getContacts(true);
},
onContactsSearchInput() {
if (this.searchDebounceTimeout) clearTimeout(this.searchDebounceTimeout);
Te6edf3this.searchDebounceTimeout = Ta5d6ffsetTimeout(() =Tff7b72> {
this.getContacts();
}, 250);
},
openAddDialog() {
this.newContactName = "";
this.newContactInput = "";
this.pendingLxmaImport = false;
this.isAddDialogOpen = true;
},
openImportDialog() {
this.importError = null;
this.isImportDialogOpen = true;
},
closeImportDialog() {
this.isImportDialogOpen = false;
this.importError = null;
},
onImportFileChange(event) {
const file = event.target.files?.[0];
if (!file) return;
this.importError = null;
const reader = new FileReader();
reader.onload = (e) => {
try {
const json = JSON.parse(e.target?.result || "{}");
const contacts = json.contacts ?? (Array.isArray(json) ? json : []);
if (!Array.isArray(contacts) || contacts.length === 0) {
this.importError = this.$t("contacts.import_failed");
return;
}
this.importContacts(contacts);
} catch {
this.importError = this.$t("contacts.import_failed");
}
};
reader.readAsText(file);
event.target.value = "";
},
async importContacts(contacts) {
try {
const response = await window.api.post("/api/v1/telephone/contacts/import", {
contacts,
});
const added = response.data?.added ?? 0;
ToastUtils.success(this.$t("contacts.import_success", { added }));
this.closeImportDialog();
await this.getContacts();
} catch (e) {
this.importError = e?.response?.data?.message || this.$t("contacts.import_failed");
}
},
async exportContacts() {
try {
const response = await window.api.get("/api/v1/telephone/contacts/export");
const contacts = response.data?.contacts ?? [];
const blob = new Blob([JSON.stringify({ contacts }, null, 2)], {
type: "application/json",
});
await DownloadUtils.downloadFile("contacts_export.json", blob);
ToastUtils.success(this.$t("contacts.export_success"));
} catch (e) {
ToastUtils.error(e?.response?.data?.message || this.$t("contacts.export_failed"));
}
},
closeAddDialog() {
this.isAddDialogOpen = false;
this.pendingLxmaImport = false;
},
openMyIdentityDialog() {
this.isMyIdentityDialogOpen = true;
},
parseLxmaUri(input) {
const normalized = input.trim();
const match = normalized.match(/^lxma:\/\/([0-9a-f]{32}):([0-9a-f]{64}|[0-9a-f]{128})$/i);
if (!match) return null;
return {
destinationHash: match[1].toLowerCase(),
publicKeyHex: match[2].toLowerCase(),
normalizedUri: `lxma://${match[1].toLowerCase()}:${match[2].toLowerCase()}`,
};
},
extractDestinationHash(input) {
const raw = input.trim().toLowerCase();
if (/^[0-9a-f]{32}$/.test(raw)) return raw;
const lxmfMatch = raw.match(/^lxmf:\/\/([0-9a-f]{32})$/);
if (lxmfMatch) return lxmfMatch[1];
const lxmMatch = raw.match(/^lxm:\/\/([0-9a-f]{32})$/);
if (lxmMatch) return lxmMatch[1];
return null;
},
async submitAddContact() {
if (!this.newContactInput || this.isSubmitting) return;
this.isSubmitting = true;
try {
const lxmaData = this.parseLxmaUri(this.newContactInput);
if (lxmaData) {
this.pendingLxmaImport = true;
WebSocketConnection.send(
JSON.stringify({
type: "lxm.ingest_uri",
uri: lxmaData.normalizedUri,
})
);
ToastUtils.info(this.$t("contacts.importing_lxma"));
return;
}
const destinationHash = this.extractDestinationHash(this.newContactInput);
if (!destinationHash) {
ToastUtils.error(this.$t("contacts.invalid_contact_input"));
return;
}
const existing = await window.api.get(`/api/v1/telephone/contacts/check/${destinationHash}`);
if (existing.data?.id) {
ToastUtils.info(this.$t("contacts.contact_already_exists"));
return;
}
await window.api.post("/api/v1/telephone/contacts", {
name: this.newContactName?.trim() || `Contact ${destinationHash.slice(0, 8)}`,
lxmf_address: destinationHash,
});
ToastUtils.success(this.$t("contacts.contact_added"));
this.closeAddDialog();
await this.getContacts();
} catch (e) {
ToastUtils.error(e.response?.data?.message || this.$t("contacts.failed_add_contact"));
} finally {
this.isSubmitting = false;
}
},
async onWebsocketMessage(message) {
let json;
try {
json = JSON.parse(message.data);
} catch {
return;
}
if (json.type === "lxm.ingest_uri.result" && this.pendingLxmaImport) {
this.pendingLxmaImport = false;
this.isSubmitting = false;
if (json.status === "success" && json.ingest_type === "lxma_contact") {
ToastUtils.success(json.message || this.$t("contacts.contact_added"));
this.closeAddDialog();
await this.getContacts();
} else if (json.status === "error") {
ToastUtils.error(json.message || this.$t("contacts.failed_add_contact"));
}
}
},
async removeContact(contact) {
this.closeContextMenu();
if (!contact?.id) return;
const duplicates = this.contacts.filter((c) => c.name === contact.name && c.id !== contact.id);
const confirmMsg =
duplicates.length > 0
? `${this.$t("contacts.remove_contact_confirm")}\n\n(${duplicates.length} additional duplicate${duplicates.length > 1 ? "s" : ""} named "${contact.name}" will also be removed)`
: this.$t("contacts.remove_contact_confirm");
if (!window.confirm(confirmMsg)) return;
try {
const ids = [contact.id, ...duplicates.map((c) => c.id)];
for (const id of ids) {
await window.api.delete(`/api/v1/telephone/contacts/${id}`);
}
ToastUtils.success(this.$t("contacts.contact_removed"));
await this.getContacts();
} catch {
ToastUtils.error(this.$t("contacts.failed_remove_contact"));
}
},
async editContactName(contact) {
this.closeContextMenu();
if (!contact?.id) return;
const name = await DialogUtils.prompt(this.$t("contacts.enter_contact_name"), contact.name);
if (name == null || name === contact.name) return;
try {
const duplicates = this.contacts.filter((c) => c.name === contact.name && c.id !== contact.id);
const ids = [contact.id, ...duplicates.map((c) => c.id)];
for (const id of ids) {
await window.api.patch(`/api/v1/telephone/contacts/${id}`, { name });
}
const allContacts = [contact, ...duplicates];
for (const c of allContacts) {
const destHash = c.remote_destination_hash || c.lxmf_address || c.remote_identity_hash;
if (destHash && name.length > 0) {
await window.api.post(`/api/v1/destination/${destHash}/custom-display-name/update`, {
display_name: name,
});
}
}
ToastUtils.success(this.$t("contacts.contact_updated"));
await this.getContacts();
} catch {
ToastUtils.error(this.$t("contacts.failed_update_contact"));
}
},
openConversation(contact) {
this.closeContextMenu();
const hash = contact.remote_destination_hash || contact.lxmf_address || contact.remote_identity_hash;
if (!hash) return;
this.$router.push({ name: "messages", params: { destinationHash: hash } });
},
callContact(contact) {
this.closeContextMenu();
const hash =
contact.remote_telephony_hash || contact.remote_destination_hash || contact.remote_identity_hash;
if (!hash) return;
this.$router.push({ name: "call", query: { destination_hash: hash, tab: "phone" } });
},
openContextMenu(event, contact) {
this.contextMenu.visible = true;
this.contextMenu.contact = contact;
this.contextMenu.x = event.clientX;
this.contextMenu.y = event.clientY;
},
closeContextMenu() {
this.contextMenu.visible = false;
this.contextMenu.contact = null;
},
async fetchContactLxmaUri(contact) {
const destinationHash = (contact?.lxmf_address || contact?.remote_identity_hash || "").toLowerCase();
if (!/^[0-9a-f]{32}$/.test(destinationHash)) return null;
try {
const response = await window.api.get("/api/v1/announces", {
params: {
destination_hash: destinationHash,
limit: 1,
},
});
const announce = response.data?.announces?.[0];
const publicKeyBase64 = announce?.identity_public_key;
if (!publicKeyBase64) return null;
const binary = atob(publicKeyBase64);
const publicKeyHex = Array.from(binary)
.map((c) => c.charCodeAt(0).toString(16).padStart(2, "0"))
.join("");
if (publicKeyHex.length !== 64 && publicKeyHex.length !== 128) return null;
return `lxma://${destinationHash}:${publicKeyHex}`;
} catch {
return null;
}
},
async copyContactUri(contact) {
this.closeContextMenu();
const lxmaUri = await this.fetchContactLxmaUri(contact);
if (lxmaUri) {
await this.copyToClipboard(lxmaUri, this.$t("contacts.contact_uri_copied"));
return;
}
const destinationHash = contact?.lxmf_address || contact?.remote_identity_hash;
if (destinationHash) {
await this.copyToClipboard(`lxmf://${destinationHash}`, this.$t("contacts.contact_uri_copied"));
} else {
ToastUtils.error(this.$t("contacts.failed_build_contact_uri"));
}
},
async shareContact(contact) {
this.closeContextMenu();
const lxmaUri = await this.fetchContactLxmaUri(contact);
const destinationHash = contact?.lxmf_address || contact?.remote_identity_hash;
const fallback = destinationHash ? `lxmf://${destinationHash}` : null;
const uri = lxmaUri || fallback;
if (!uri) {
ToastUtils.error(this.$t("contacts.failed_build_contact_uri"));
return;
}
await this.shareUri(uri);
},
async shareUri(uri) {
try {
if (navigator.share) {
await navigator.share({
title: this.$t("contacts.share"),
text: uri,
});
return;
}
} catch {
// ignore and fallback to clipboard
}
await this.copyToClipboard(uri, this.$t("contacts.contact_uri_copied"));
},
async copyToClipboard(value, successMessage) {
try {
await navigator.clipboard.writeText(value);
ToastUtils.success(successMessage || this.$t("common.copied"));
} catch {
ToastUtils.error(this.$t("common.failed_to_copy"));
}
},
async pasteFromClipboard() {
try {
this.newContactInput = await navigator.clipboard.readText();
} catch {
ToastUtils.error(this.$t("messages.failed_read_clipboard"));
}
},
async openScannerDialog() {
this.isScannerDialogOpen = true;
this.scannerError = null;
await this.$nextTick();
await this.startScanner();
},
async startScanner() {
if (!this.cameraSupported) {
this.scannerError = this.$t("contacts.camera_not_supported");
return;
}
try {
const stream = await startCameraStream();
if (!stream.getVideoTracks().length) {
this.scannerError = this.$t("contacts.camera_not_found");
stream.getTracks().forEach((track) => track.stop());
return;
}
this.scannerStream = stream;
const video = this.$refs.scannerVideo;
if (!(await attachStreamToVideo(stream, video))) {
return;
}
this.detectQrLoop();
} catch (e) {
this.scannerError = this.describeCameraError(e);
}
},
detectQrLoop() {
if (!this.isScannerDialogOpen) return;
const video = this.$refs.scannerVideo;
decodeQrFromVideo(video)
.then((qr) => {
if (!this.isScannerDialogOpen) {
return;
}
if (qr) {
this.newContactInput = qr;
this.closeScannerDialog();
ToastUtils.success(this.$t("contacts.qr_scanned"));
return;
}
this.scannerAnimationFrame = requestAnimationFrame(() => this.detectQrLoop());
})
.catch(() => {
if (this.isScannerDialogOpen) {
this.scannerAnimationFrame = requestAnimationFrame(() => this.detectQrLoop());
}
});
},
stopScanner() {
if (this.scannerAnimationFrame) {
cancelAnimationFrame(this.scannerAnimationFrame);
this.scannerAnimationFrame = null;
}
if (this.scannerStream) {
this.scannerStream.getTracks().forEach((track) => track.stop());
this.scannerStream = null;
}
},
closeScannerDialog() {
this.isScannerDialogOpen = false;
this.stopScanner();
},
describeCameraError(error) {
return describeQrCameraError(error, {
permissionDenied: this.$t("contacts.camera_permission_denied"),
notFound: this.$t("contacts.camera_not_found"),
failed: this.$t("contacts.camera_failed"),
});
},
},
};
Tff7b72</script>
Tff7b72<style scopedTff7b72>
@reference "../../style.css";
.glass-card {
@apply bg-white/95 dark:bg-zinc-900/85 backdrop-blur-sm border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xs p-4;
}
.input-field {
@apply bg-gray-50/90 dark:bg-zinc-900/80 border border-gray-200 dark:border-zinc-700 text-sm rounded-xl focus:ring-2 focus:ring-blue-400 focus:border-blue-400 dark:focus:ring-blue-500 dark:focus:border-blue-500 block w-full p-2.5 text-gray-900 dark:text-gray-100 transition;
}
.primary-chip {
@apply inline-flex items-center gap-1 rounded-xl bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 text-xs font-semibold transition disabled:opacity-60;
}
.secondary-chip {
@apply inline-flex items-center gap-1 rounded-xl bg-gray-100 hover:bg-gray-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-200 px-3 py-2 text-xs font-semibold transition;
}
Tff7b72</style>
Served by rngit 1.5.2 - Generated in 0.05s